Skip to content

[COLLECTIONS-776] Fix expiration bypass in PassiveExpiringMap when wrapped in SynchronizedMap#694

Open
s8sankalp wants to merge 8 commits into
apache:masterfrom
s8sankalp:COLLECTIONS-776
Open

[COLLECTIONS-776] Fix expiration bypass in PassiveExpiringMap when wrapped in SynchronizedMap#694
s8sankalp wants to merge 8 commits into
apache:masterfrom
s8sankalp:COLLECTIONS-776

Conversation

@s8sankalp

Copy link
Copy Markdown
Contributor

Description

When PassiveExpiringMap is decorated with Collections.synchronizedMap(), calling view methods like entrySet(), keySet(), or values() returns cached view decorators managed by SynchronizedMap. This bypasses PassiveExpiringMap's view methods and avoids triggering the passive eviction logic removeAllExpired() during iteration, resulting in expired entries remaining in the map indefinitely.

This PR introduces custom collection views (EntrySet, KeySet, ValuesCollection) and their respective iterators to:

  1. Ensure removeAllExpired() is triggered on all read and write collection view operations.
  2. Propagate iterator remove() actions to the map's internal expirationMap.
  3. Preserve the O(1) complexity of remove() and removeAll() for the set views.

Verification

  • Added JUnit regression test testCollectionsSynchronizedMapExpiration() verifying correct passive eviction when iterating/accessing collection views of a synchronized PassiveExpiringMap.
  • Added JUnit regression test testCollectionViewRemoval() verifying correct cleanup of expirationMap when elements are removed via view iterators.
  • All unit tests pass successfully.

@garydgregory

Copy link
Copy Markdown
Member

-1 since applying only the test side of the patch doesn't cause any tests to fail. TDD is calling...

@garydgregory garydgregory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1, see my comment.

@s8sankalp s8sankalp requested a review from garydgregory June 24, 2026 05:21
@garydgregory garydgregory changed the title COLLECTIONS-776: Fix expiration bypass in PassiveExpiringMap when wrapped in SynchronizedMap [COLLECTIONS-776] Fix expiration bypass in PassiveExpiringMap when wrapped in SynchronizedMap Jun 27, 2026

@garydgregory garydgregory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @s8sankalp

Thank you for the PR.

There is untested code in this PR. To see what's missing:

  • Run: mvn clean verify site
  • Open the site in: target/site/index.html
  • Navigate to the JaCoCo report

You'll see in red what's missing, for example, null inputs on the removeAll() and retainAll() methods in the new classes.

In unit tests, this is an anti-pattern unless you are specifically testing for size() implementation instead of state:

assertEquals(0, entrySet.size());

Update or add:

assertTrue(entrySet.isEmpty());

You don't need blanks lines all over the place since you nicely document the steps with // comments.

@s8sankalp s8sankalp requested a review from garydgregory June 27, 2026 20:01

@garydgregory garydgregory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @s8sankalp

Why does PassiveExpiringMap.EntrySet.iterator() call PassiveExpiringMap.removeAllExpired(long) and PassiveExpiringMap.KeySet.iterator() does not?

Isn't that call either superfluous in the former or missing in the later?

Commenting out the call of PassiveExpiringMap.removeAllExpired(long) in PassiveExpiringMap.EntrySet.iterator() doesn't cause any test to fail.

This tells me at lease one test is missing.

Please check.

@s8sankalp

s8sankalp commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

Hello @s8sankalp

Why does PassiveExpiringMap.EntrySet.iterator() call PassiveExpiringMap.removeAllExpired(long) and PassiveExpiringMap.KeySet.iterator() does not?

Isn't that call either superfluous in the former or missing in the later?

Commenting out the call of PassiveExpiringMap.removeAllExpired(long) in PassiveExpiringMap.EntrySet.iterator() doesn't cause any test to fail.

This tells me at lease one test is missing.

Please check.

hi @garydgregory

The call to removeAllExpired() is not needed in KeySet.iterator() because both the key set and values iterators delegate directly to entrySet().iterator(), which already triggers the eviction check. Conversely, the eviction call in EntrySet.iterator() is necessary to support cached views. If a client caches a collection view (like the entry set) and waits for entries to expire before obtaining an iterator, the iterator must run the eviction check to avoid returning those expired elements. To verify this behavior and prevent future regressions, I have added a new unit test asserting that iterators created from cached views after expiration do not return any elements; this test now correctly fails if the eviction call in EntrySet.iterator() is removed.

@s8sankalp s8sankalp requested a review from garydgregory June 28, 2026 15:20
@s8sankalp

Copy link
Copy Markdown
Contributor Author

Hi @garydgregory ,

Just wanted to check in on this one. I replied above about why removeAllExpired() is needed in EntrySet.iterator() but not in KeySet.iterator(), and I added a new unit test to cover the cached view eviction case you pointed out. Let me know if that clears things up or if you'd like me to change anything else.
Thanks again for reviewing this, really appreciate the feedback.

@garydgregory

Copy link
Copy Markdown
Member

@s8sankalp
That's a ton of new code to review. I need to consider if there's a simpler solution. More later.

@s8sankalp

Copy link
Copy Markdown
Contributor Author

@garydgregory
Take your time

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a correctness gap in PassiveExpiringMap’s passive-eviction behavior when the map is wrapped by Collections.synchronizedMap(), where cached collection views (entrySet(), keySet(), values()) could bypass view-level expiration triggers and allow expired entries to linger indefinitely.

Changes:

  • Introduces custom EntrySet, KeySet, and ValuesCollection view wrappers (and iterators) to ensure view operations trigger expiration checks and iterator removals clean up expirationMap.
  • Adds regression tests covering synchronized-map view caching and iterator-triggered expiration.
  • Records the fix in changes.xml under COLLECTIONS-776.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.

File Description
src/main/java/org/apache/commons/collections4/map/PassiveExpiringMap.java Replaces raw map views with custom view decorators/iterators to prevent expiration bypass and to propagate iterator removals to expirationMap.
src/test/java/org/apache/commons/collections4/map/PassiveExpiringMapTest.java Adds regression tests for synchronized-map view caching and iterator-triggered expiration; adds tests around view removals and null inputs.
src/changes/changes.xml Adds a changelog entry for COLLECTIONS-776.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +591 to +595
@Override
public boolean removeAll(final Collection<?> coll) {
if (coll == null) {
return false;
}
Comment on lines +613 to +617
@Override
public boolean retainAll(final Collection<?> coll) {
if (coll == null) {
return false;
}
Comment on lines +731 to +735
@Override
public boolean removeAll(final Collection<?> coll) {
if (coll == null) {
return false;
}
Comment on lines +753 to +757
@Override
public boolean retainAll(final Collection<?> coll) {
if (coll == null) {
return false;
}
Comment on lines +874 to +878
@Override
public boolean removeAll(final Collection<?> coll) {
if (coll == null) {
return false;
}
Comment on lines +890 to +894
@Override
public boolean retainAll(final Collection<?> coll) {
if (coll == null) {
return false;
}
Comment on lines +331 to +339
// entrySet
assertFalse(map.entrySet().removeAll(null));
assertFalse(map.entrySet().retainAll(null));
// keySet
assertFalse(map.keySet().removeAll(null));
assertFalse(map.keySet().retainAll(null));
// values
assertFalse(map.values().removeAll(null));
assertFalse(map.values().retainAll(null));
Comment on lines +300 to +325
@Test
void testCollectionViewRemoval() {
final PassiveExpiringMap<String, String> map = new PassiveExpiringMap<>(10000L);
map.put("a", "b");
map.put("c", "d");
map.put("e", "f");
// Remove via entrySet iterator
final Iterator<Map.Entry<String, String>> entryIter = map.entrySet().iterator();
assertTrue(entryIter.hasNext());
final Map.Entry<String, String> entry = entryIter.next();
final String removedKey = entry.getKey();
entryIter.remove();
assertFalse(map.containsKey(removedKey));
// Remove via keySet iterator
final Iterator<String> keyIter = map.keySet().iterator();
assertTrue(keyIter.hasNext());
final String key = keyIter.next();
keyIter.remove();
assertFalse(map.containsKey(key));
// Remove via values iterator
final Iterator<String> valIter = map.values().iterator();
assertTrue(valIter.hasNext());
final String val = valIter.next();
valIter.remove();
assertFalse(map.containsValue(val));
}
@s8sankalp

Copy link
Copy Markdown
Contributor Author

Hi @garydgregory ,

Thanks for the review!

I've addressed the feedback in the latest update:

  • Updated the custom EntrySet, KeySet, and ValuesCollection implementations to align removeAll(null) and retainAll(null) with standard Collection semantics by rejecting null arguments via Objects.requireNonNull(...).
  • Updated the regression tests accordingly to expect NullPointerException for null inputs instead of returning false.
  • Added additional regression coverage to verify that removals performed through collection views and iterators also clean up the associated expiration metadata, preventing stale entries from remaining in expirationMap.

The changes have been pushed to the PR branch for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants